fix(e2e): drive the real provider->verifier path instead of self-signed tokens - #9
Merged
Merged
Conversation
…ed tokens The cross-component suite verified liveness against tokens it minted itself. It hand-signed a JWT with the shared HS256 secret, self-introspected it, and called that an end-to-end test — so the actual provider->verifier contract (claim names, `typ`, `iss`, `aud`, `sub`) was never exercised, and neither were the provider's Redis-backed grant paths. The old token-flow test even sent `scopes: ['read']` as an array while the verifier reads `scope` as a string, and nothing caught it. Every token on the happy path is now minted by the provider through `login -> /authorize (PKCE) -> /token`. Hand-signing survives only where the provider cannot produce the input — an expired token, a wrong issuer, a wrong audience, a scopeless token — and each of those says so. What the suite now proves that it did not before: - The full grant path runs: session login, /authorize with PKCE/S256 against a first-party client, code redemption, refresh rotation, /userinfo, /introspect, and policy-verifier /verify with the access token the provider actually minted. - The access-token envelope is the deployed one: `typ: at+jwt`, the canonical `iss`, an `aud` that came from the RFC 8707 `resource` parameter, and `scope` as a space-delimited string — with an explicit assertion that no `scopes` array exists. - `sub` survives the trip from the Store through the token to the verifier's decision, which is what an AT-sub-from-session regression would break. - Only access tokens are decision inputs. The id_token and the refresh token from the same grant are both rejected at /verify. The `typ` header is the only discriminator the verifier has, so all three `typ` values are pinned on the provider side too — a change to either end fails here. - Refresh rotates, and replaying a consumed refresh token is refused as `replay_detected` rather than a generic stale-token error. - Omitting `resource` on refresh silently drops the audience to the client id. That is RFC 8707 §2.2 behaving as written, and it makes the refreshed token useless at the resource server, so the branch is pinned rather than left to be rediscovered. - /authorize refuses a client not marked `firstParty: true` and refuses a user whose email the Store has not verified, both as redirect errors per RFC 6749 §4.1.2.1 with no code minted. - A code redeemed with the wrong PKCE verifier is refused. Infrastructure the real path required: - Sets `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`, which was missing. One ioredis socket backs the refresh-token-family store, the four user-session stores, the rate limiter and the authorization-code repository; it defaulted to `redis://localhost:6379`, nothing inside the container. The legacy `CLIENT_CODE_ENDPOINT_URI` sitting there instead is a no-op once `oauth.code.adapter = "redis"` routes codes onto that socket, and is deleted. - Moves the remaining shared stores onto Redis and declares `DEPLOYMENT_MODE=multi`, so the provider audits the block itself: boot fails naming every offender if a store drifts back into process memory. The drift this issue was filed for becomes a red build. - Adds tests/provider/clients.yaml and tests/provider/users.yaml. The template ships an empty clients.yaml, no users.yaml, and defaults the user repository to "http", so the real login path could not have run without them. - Enables `OAUTH_RESOURCE_INDICATOR_ENABLED` so the audience the verifier pins is reachable, and `OAUTH_REQUIRE_EMAIL_VERIFIED` so that gate is exercised rather than merely off. - Adds tests/shared/oauthFlow.js, a dependency-free driver both packages import. It sends `Origin` on /session/login, which the current provider accepts and auth.provider#344 will require. Local run: `make test-e2e` green from a clean state — 17 token-flow tests and 12 abac tests — against auth.provider b3317687 and auth.policy-verifier c2dba9e. Closes #3 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR upgrades the cross-component E2E suite to exercise the real OAuth/OIDC provider→verifier contract by driving login → /authorize (PKCE) → /token → refresh → userinfo/introspect, instead of validating liveness against self-signed JWTs.
Changes:
- Introduces a shared, dependency-free OAuth flow driver (
tests/shared/oauthFlow.js) to mint provider-issued tokens for the happy path. - Updates token-flow and ABAC E2E tests to assert real token envelope/claims (
typ,iss,aud,sub,scope) and refresh rotation/replay behavior, keeping hand-signed tokens only for negative envelope cases. - Fixes E2E infra drift by wiring the provider’s Redis-backed stores and mounting required YAML fixtures (clients/users) in
tests/docker-compose.yml.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/token-flow/index.test.js | Replaces self-signed token checks with real provider grant flow assertions and adds refresh/authorize/proxy coverage. |
| tests/shared/oauthFlow.js | Adds shared PKCE + authorize/token/refresh/userinfo/introspect/verify driver used by multiple E2E packages. |
| tests/provider/users.yaml | Adds YAML user fixtures to enable real /session/login and email-verified gating paths. |
| tests/provider/clients.yaml | Adds YAML client registrations including first-party gating and allowed audiences/scopes for RFC 8707. |
| tests/docker-compose.yml | Mounts fixtures and enables Redis-backed stores + multi deployment audit to ensure real grant paths are exercised. |
| tests/abac/index.test.js | Switches verifier tests to provider-minted access tokens and pins rejection of id/refresh tokens plus envelope negatives. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The proxy's "allows a request carrying a provider-issued token" test hit
/_healthcheck, which is mounted ahead of the auth middleware and never reaches
it. A 200 there proves the process is up and nothing else — the token was
never introspected, so the assertion held whether or not the proxy accepted
it. That is the same shape of hole this PR exists to close: a test that reads
like it proves something it does not.
The proxy tests now run against /oauth/userinfo, a route the proxy actually
authenticates, and assert the response body rather than the status alone.
Which side rejected a request is decidable from the body — the proxy answers
{"code":401,"message":"Invalid Token"} and the provider answers the RFC 6750
{"error":"invalid_token",...} — so every case now pins WHO made the decision.
Status-only assertions would let "the proxy forwards everything and the
upstream happens to reject it" pass as "the proxy validates".
What this now proves:
- A provider-minted access token sent through the proxy comes back with the
upstream's own /oauth/userinfo body. That is the whole round-trip: the proxy
introspected the token against the provider (AUTH_MODE=validation,
INTROSPECT_URL), got `active: true`, forwarded with the Authorization header
intact, and returned what the upstream produced.
- A garbage token, an expired token and an id_token are each refused BY THE
PROXY, with the proxy's own error shape — introspection returned inactive
and the request never reached the upstream. The id_token case means the
"only access tokens are credentials" rule holds at the proxy too, reached by
introspection rather than by the `typ` pin the policy-verifier uses.
- A non-Bearer scheme is refused as 400 Invalid Token Type.
- An unauthenticated request is genuinely passed through: the proxy does not
introspect, and the RFC 6750 error shape coming back is the evidence that
the request reached the provider instead of being short-circuited.
The /_healthcheck check is kept as what it always was — a liveness probe —
renamed and commented so it cannot be misread as an auth assertion again.
Local run: `make test-e2e` green from a clean state, 20 token-flow tests and
12 abac tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… missing `login()` read Set-Cookie only through `Headers#getSetCookie()` and fell back to `[]`, so a Node build without that method produced an empty cookie string and the failure surfaced much later as an unexplained "/authorize did not return a code". `getSetCookie()` stays the primary reader — it is the only one that keeps multiple Set-Cookie headers separate, where `get()` folds them into a single comma-joined string — with the plain header as a fallback for older Node/undici. A successful login that yields no readable session cookie now throws where it happened, naming the cause. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
The cross-component suite verified liveness against tokens it minted itself. It hand-signed a JWT with the shared HS256 secret, self-introspected it, and called that an end-to-end test. The real
provider -> verifiercontract — claim names,typ,iss,aud,sub— was never exercised, and neither were the provider's Redis-backed grant paths. The old token-flow test even sentscopes: ['read'](array) while the verifier readsscope(string), and nothing caught it.Every token on the happy path is now minted by the provider through
login → /authorize (PKCE) → /token. Hand-signing survives only where the provider cannot produce the input — an expired token, a wrong issuer, a wrong audience, a scopeless token — and each of those says so in a comment.Infrastructure the real path required
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL, which was simply missing. One ioredis socket backs the refresh-token-family store, the four user-session stores, the rate limiter and the authorization-code repository; it defaulted toredis://localhost:6379— nothing inside the container. The legacyCLIENT_CODE_ENDPOINT_URIsitting there instead is a no-op onceoauth.code.adapter = "redis"routes codes onto that socket, and is deleted.DEPLOYMENT_MODE=multiafter moving the remaining shared stores onto Redis, so the provider audits the block itself: boot fails naming every offender if a store drifts back into process memory. The drift this issue was filed for becomes a red build rather than a suite that quietly stops exercising Redis.tests/provider/clients.yamlandtests/provider/users.yaml. The template ships an emptyclients.yaml, nousers.yaml, and defaults the user repository tohttp(which needs an identity service this rig does not have), so the real login path could never have run without them.OAUTH_RESOURCE_INDICATOR_ENABLED(so the audience the verifier pins is reachable at all) andOAUTH_REQUIRE_EMAIL_VERIFIED(so that gate is exercised rather than merely off).tests/shared/oauthFlow.js, a dependency-free driver both packages import.What the suite now proves vs before
/authorize+/token/authorize+ PKCEreplay_detected/userinfo/verifyinput/verifyDEPLOYMENT_MODE=multiscopevsscopesdriftSpecifically, the suite now pins:
typ: at+jwt, canonicaliss, anaudthat came from the RFC 8707resourceparameter, andscopeas a space-delimited string — with an explicit assertion that noscopesarray exists.subsurviving the trip from the Store, through the token, into the verifier's decision — what an AT-sub-from-session regression would break.id_tokenandrefresh_tokenfrom the same grant are both rejected at/verify. Thetypheader is the verifier's only discriminator, so all threetypvalues are pinned on the provider side too — a change at either end fails here. There is a comment saying exactly this so the tests do not get weakened into "some 4xx".resourceon refresh silently drops the audience to the client id. That is RFC 8707 §2.2 behaving as written, and it makes the refreshed token useless at the resource server, so the branch is pinned rather than left to be rediscovered./authorizerefusing a client not markedfirstParty: true, and refusing a user whose email the Store has not verified — both as redirect errors per RFC 6749 §4.1.2.1 with no code minted.Local run
make test-e2egreen from a clean state, againstauth.providerb3317687andauth.policy-verifierc2dba9e:Notes
clients.yamlcould not expressfirstParty: true(ClientEntrySchemawas.strict()without the key, andfindByIddid not project it), so/authorizewas unusable for every file-backed deployment. Fixed by fix(core): accept and surface firstParty in the file-backed client repositories auth.provider#342; the test-shape gap that hid it is [test] The first-party invariant is only tested against hand-stubbed ClientRepositories auth.provider#343./session/loginis called with anOriginheader. The current provider accepts it and auth.provider#344 will require it, so this is forward-compatible.Closes #3
🤖 Generated with Claude Code